feat!: abstract roots and union classes - #46
Merged
Conversation
…ule boundary The emit fixture only ever compiled a root declared beside its variants, where TypeScript synthesises a local `declare abstract class` for it. Split into vocabulary / organization / root / index so the two-compiler pass also covers the path where the root's instance type has to be named across a module boundary. The widths in `index.ts` are untouched: the emitted `Invoice_base` still expands the thirty-member dunning enum inline.
Three gaps: a root's class-body field is never initialised and its statics are not inherited (`extend` rewires the instance prototype only), and a variant declaring `computed` replaces the root's derived fields rather than adding to them. Plus the three cheap ones the review named — the shadowing test asserted only `equals`, `Business.describe()` was never called, and nothing replaced the deleted `extend.spec.ts` assertion that two variants with matching data are not equal.
… block Findings the review named. A root's class-body fields are never initialised and its statics are not inherited — `extend` rewires the instance prototype only — so `declaration.md` now says both, alongside where a root goes and which options replace rather than merge (`computed` included). `union.ts`'s module doc still taught the removed value form and was orphaned between two adjacent JSDoc blocks, so `union` rendered undocumented; it now sits on the function and shows the class form. Plus the smaller ones: `__base` is the root's *instance* type, a union of members from different roots shares nothing even when the roots are related, an intermediate cannot add fields, and CLAUDE.md's module count.
The successor to extend.spec.ts's "even with matching data" compared Personal and Business, which differ in `kind` — so `deepEqual` returned false before `equals`' `instanceof Base` guard mattered, and the test passed with the guard deleted. Two variants of one root adding the same field under the same schema, with identical data, leave the guard as the only thing that can say no. Also pins the `private` field case the docs claim, and corrects three comments the split left stale: emit-guards.ts named `index.ts` for widths now in `vocabulary.ts`, and `extend`'s published JSDoc still said it inherits "the class body".
There was a problem hiding this comment.
Pull request overview
This PR evolves @btravstack/entity’s declaration model to better support discriminated unions by introducing tagless abstract roots for shared fields/behaviour and by making Entity.union(...) return a class (so unions can use the same “class extends …” idiom as entities). It also makes entities explicitly final by removing .extend() from concrete entities, consolidating extension onto abstract roots.
Changes:
- Add
Entity.abstract(name)(fields, options?)backed by a newbase.tsimplementation that rebuilds variants from recorded declarations and rewires instance prototypes for trueinstanceofnarrowing + shared behaviour. - Change
Entity.union(...)to return a class with a sealed (defecting) constructor, a shared-root construct signature forclass X extends Entity.union(...) {}, and a newEntity.Instance<T>helper for exact instance unions. - Update/replace tests, examples, docs, and changeset/migration guidance to reflect “entities are final; roots are extensible; unions are classes”.
Reviewed changes
Copilot reviewed 34 out of 34 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| README.md | Updates public-facing quick overview to “entities are final”, introduces roots + union class idiom. |
| packages/entity/src/union.ts | Makes union() return a class; adds shared-root construct signature + __instance carrier and runtime constructor defect. |
| packages/entity/src/union.test-d.ts | Adds d.ts coverage for union-as-class typing and Entity.Instance on unions. |
| packages/entity/src/union.spec.ts | Adds runtime coverage for union-as-class dispatch, schema composition, and “no instances”. |
| packages/entity/src/types.ts | Adds RootInstance, BehaviourOf, AbstractEntity, updates EntityStatic with root-behaviour parameter + __base/__instance, removes entity .extend. |
| packages/entity/src/index.ts | Exports AbstractEntity at top-level for declaration emit correctness. |
| packages/entity/src/extend.test-d.ts | Deleted (extension semantics moved under abstract-root tests). |
| packages/entity/src/extend.spec.ts | Deleted (coverage migrated to base.*). |
| packages/entity/src/entity.ts | Integrates declaration recording via record(), wires Entity.abstract, adds Entity.Instance namespace type. |
| packages/entity/src/entity.test-d.ts | Adds type-level assertion that concrete entities no longer have .extend. |
| packages/entity/src/base.ts | New module implementing abstract roots + root .extend() rebuilding and prototype rewiring. |
| packages/entity/src/base.test-d.ts | New d.ts tests for root/variant typing, abstract enforcement, and Entity.Instance on entities. |
| packages/entity/src/base.spec.ts | New runtime tests for root behaviour inheritance, prototype semantics, option inheritance, and “does not carry” cases. |
| packages/entity/README.md | Mirrors root/union-class guidance for package-local README. |
| examples/billing-domain/src/vocabulary.ts | Splits vocabulary into its own module (emit fixture + clarity). |
| examples/billing-domain/src/root.ts | Adds exported abstract root module to exercise cross-module emit path. |
| examples/billing-domain/src/organization.ts | Extracts standalone entity into its own module. |
| examples/billing-domain/src/index.ts | Reworks billing document declarations onto root + variant model; union becomes a class. |
| examples/billing-domain/src/index.spec.ts | Adds tests asserting root behaviour, shared invariants, and per-variant overrides. |
| examples/billing-domain/src/emit-guards.ts | Extends emit fixture to cover Entity.Instance, Entity.Abstract, and root module split behavior. |
| docs/typedoc.json | Updates intentionally-not-exported list for newly introduced internal helper types. |
| docs/reference/types.md | Documents Entity.Instance and updates the declaration-emit type-name list. |
| docs/reference/declaration.md | Rewrites reference around Entity.abstract, root .extend, union-as-class, and “entity is final”. |
| docs/how-to/model-an-aggregate.md | Updates how-to to model unions via roots + union class; emphasizes union body statics-only. |
| docs/how-to/http-contract.md | Updates union example to class form. |
| docs/how-to/evolve-an-entity.md | Adds guidance for splitting one entity into root + variants. |
| docs/explanation/unions-and-roots.md | New explanation page capturing the TypeScript constraints and resulting API shape. |
| docs/explanation/sealed-construction.md | Updates terminology and guidance to “entity is final” and roots as the extension point. |
| docs/examples/index.md | Updates example descriptions to mention roots/variants. |
| docs/examples/billing-domain.md | Updates walkthrough to new module split + root/variants + union-as-class. |
| docs/api/index.md | Updates API intro to include Entity.abstract and the updated declaration-emit name count. |
| docs/.vitepress/config.ts | Adds “Unions and roots” to the explanation sidebar. |
| CLAUDE.md | Updates repository guidance/architecture notes to include roots + union class semantics. |
| .changeset/abstract-roots-and-union-classes.md | Adds changeset documenting new API and the breaking move of .extend off entities. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
6 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #45.
The problem
A domain type that is naturally a discriminated union — a shared base, two variants, a union over the discriminant — was worse to model with this library than a plain entity. The issue reports three symptoms; the third is the one that changes how you model:
TS2507—EntityUnionhad no construct signatureInstanceType<typeof A> | InstanceType<typeof B>alias, checked by nothingextenddrops class-body membersWhat the design turns on
Six measurements, taken before writing anything. Each is now a comment in the shipped source, because they are the reason the code looks like this:
TS2509— a base-constructor expression may not return a union, soAccount-as-a-type can never bePersonal | Business"Account" & "Personal"reduces tonever, then TS2509 againTS2425— any mapped subtraction (Omit, key-remapping) turns methods into function-typed properties, so a variant implementing an abstract method failstoJSON/equals/update— the intersection produces overloads and the child's winabstract is unenforceable— wrong, see belowM1 is why a union's class body is for statics. M2–M4 are why the root is tagless.
The API
Entity.abstract(name)(fields, options?)— tagless,make-less, nofactory, none of the four schema members. Its_tagisstring, andstring & "Personal"reduces to"Personal"in the variant, which is what keeps M2 from biting. The name labels its defect message and never reaches an instance.extendrewires the new base's instance prototype onto the receiver's, sovariant instanceof AccountBaseis true and a behaviour-only intermediateabstract classbetween the two is picked up for free.Entity.union(…)returns a class.Accountas a type is the members' shared root — or the empty type when they do not share one, viaSoleType, which is what keeps M1 from firing while refusing to claim a supertype the members do not have.Account.make(row)still returns the exactResult<Personal | Business, InvalidEntity>.Entity.Instance<T>collapses the hand-maintained alias to one line that cannot drift.M6 was wrong, and the correction is good news
The design spec claimed abstract members could not be enforced on variants, and prescribed
implements SomeInterfaceas a workaround. That measurement was taken against a type-literal construct signature, which carries no abstractness. The shippedBehaviourOf<This>yields the real class type, and TypeScript propagates abstractness through the intersection:So
abstract describe(): stringon a root is a compiler-enforced obligation on every variant.implementsships nowhere. Pinned by an@ts-expect-errorinbase.test-d.ts.The union deliberately strips that abstractness (
Plain<T>inunion.ts) — it has no instances, so inheriting a root's abstract members would demand implementations that could never run.What a root does not carry
The rewiring is instance-prototype only, and three consequences follow. All are documented in
reference/declaration.mdand pinned by tests:staticmembersTypeErrorat first read. Use a gettertoJSON/equals/updateA variant declaring
computedalso replaces the root's, likegeneratedandimmutable— onlyinvariantsconcatenate. That one drops a derived column rather than a rule, so it is now named explicitly.Breaking
extendis no longer on an entity — an entity is final. This is the direct consequence of M2/M3: a concrete entity cannot carry behaviour into its extension at the type level, and a runtime that carried what the type did not would be worse than not carrying it. Rather than ship twoextends with different fidelity, there is one, on the only receiver where it works.minor, per 0.x. The changeset carries the migration; every fenced example in it was compiled before shipping.The consumer gate
EntityStaticgains a sixth type parameter carrying the root's instance type, which is exactly theTS7056band issues #31 and #32 shipped through. So it was measured rather than assumed:ConstructedInstance<Tag, S, A, I> & Bin the two zod slots, instead of spelling the intersection out twice more, is the headroom the parameter spends. Recorded intypes.ts.examples/billing-domainis now four modules, so a root crossing a module boundary is under the two-compiler gate for the first time. Verified:index.d.tsopens withimport { BillingDocumentBase } from "./root.js"rather than a synthesised localdeclare abstract class.Organizationstays a plain, rootless entity — it is what keepsSoleType'sRecord<never, never>branch exercised in emit.Test plan
base.spec.ts/base.test-d.ts— fields and options carry over, shared behaviour runs on a variant,instanceofthe root, behaviour-only intermediate root, the root has no instances, the seal still refuses a bare subclass, and the three "does not carry" cases aboveinstanceof Baseguard removed the test fails; restored, it passes. The shape it replaced passed guardless, so it pinned nothingunion.spec.ts/union.test-d.ts— the class idiom,Account-as-a-type, the exact member union underP.tag, the empty-type fallback for members from different roots, and every pre-existing test untouchedextend.spec.ts/extend.test-d.tsdeleted, coverage mapped test-by-test intobase.*first; the one pin that inverts is "extend carries declarations, not class-body members", which is the behaviour this change reversesformat --check·lint·typecheck·test·knip·build— green in CI order, uncached, including the 5.9.3 consumer passexplanation/unions-and-roots.md, reference and both how-tos reworked, eight files carrying staleextendreferences found and fixedOne thing I could not reproduce
A review reported that an unexported cross-module root breaks consumer emit with
TS4020through a branded field. It does not reproduce, and the shape may not be expressible:extendis a call on the value, so a root another module extends must be exported. No unmeasured diagnostic was written into the docs. The file split was kept anyway, on its own merit — it puts the cross-module emit path under the gate.🤖 Generated with Claude Code